# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Bootstrap concrete specs for clingo

Spack uses clingo to concretize specs. When clingo itself needs to be bootstrapped from sources,
we need to rely on another mechanism to get a concrete spec that fits the current host.

This module contains the logic to get a concrete spec for clingo, starting from a prototype
JSON file for a similar platform.
"""
import pathlib
import sys
from typing import Dict, Optional, Tuple

import spack.vendor.archspec.cpu

import spack.compilers.config
import spack.compilers.libraries
import spack.config
import spack.platforms
import spack.spec
import spack.traverse
import spack.version

from .config import spec_for_current_python


class ClingoBootstrapConcretizer:
    def __init__(self, configuration):
        self.host_platform = spack.platforms.host()
        self.host_os = self.host_platform.default_operating_system()
        self.host_target = spack.vendor.archspec.cpu.host().family
        self.host_architecture = spack.spec.ArchSpec.default_arch()
        self.host_architecture.target = str(self.host_target)
        self.host_compiler = self._valid_compiler_or_raise()
        self.host_python = self.python_external_spec()
        if str(self.host_platform) == "linux":
            self.host_libc = self.libc_external_spec()

        self.external_cmake, self.external_bison = self._externals_from_yaml(configuration)

    def _valid_compiler_or_raise(self):
        if str(self.host_platform) == "linux":
            compiler_name = "gcc"
        elif str(self.host_platform) == "darwin":
            compiler_name = "apple-clang"
        elif str(self.host_platform) == "windows":
            compiler_name = "msvc"
        elif str(self.host_platform) == "freebsd":
            compiler_name = "llvm"
        else:
            raise RuntimeError(f"Cannot bootstrap clingo from sources on {self.host_platform}")

        candidates = [
            x
            for x in spack.compilers.config.CompilerFactory.from_packages_yaml(spack.config.CONFIG)
            if x.name == compiler_name
        ]
        if not candidates:
            raise RuntimeError(
                f"Cannot find any version of {compiler_name} to bootstrap clingo from sources"
            )
        candidates.sort(key=lambda x: x.version, reverse=True)
        best = candidates[0]
        # Get compilers for bootstrapping from the 'builtin' repository
        best.namespace = "builtin"
        # If the compiler does not support C++ 14, fail with a legible error message
        try:
            _ = best.package.standard_flag(language="cxx", standard="14")
        except RuntimeError as e:
            raise RuntimeError(
                "cannot find a compiler supporting C++ 14 [needed to bootstrap clingo]"
            ) from e
        return candidates[0]

    def _externals_from_yaml(
        self, configuration: "spack.config.Configuration"
    ) -> Tuple[Optional["spack.spec.Spec"], Optional["spack.spec.Spec"]]:
        packages_yaml = configuration.get("packages")
        requirements = {"cmake": "@3.20:", "bison": "@2.5:"}
        selected: Dict[str, Optional["spack.spec.Spec"]] = {"cmake": None, "bison": None}
        for pkg_name in ["cmake", "bison"]:
            if pkg_name not in packages_yaml:
                continue

            candidates = packages_yaml[pkg_name].get("externals", [])
            for candidate in candidates:
                s = spack.spec.Spec(candidate["spec"], external_path=candidate["prefix"])
                if not s.satisfies(requirements[pkg_name]):
                    continue

                if not s.intersects(f"arch={self.host_architecture}"):
                    continue

                selected[pkg_name] = self._external_spec(s)
                break
        return selected["cmake"], selected["bison"]

    def prototype_path(self) -> pathlib.Path:
        """Path to a prototype concrete specfile for clingo"""
        parent_dir = pathlib.Path(__file__).parent
        result = parent_dir / "prototypes" / f"clingo-{self.host_platform}-{self.host_target}.json"
        if str(self.host_platform) == "linux":
            # Using aarch64 as a fallback, since it has gnuconfig (x86_64 doesn't have it)
            if not result.exists():
                result = parent_dir / "prototypes" / f"clingo-{self.host_platform}-aarch64.json"

        elif str(self.host_platform) == "freebsd":
            result = parent_dir / "prototypes" / f"clingo-{self.host_platform}-amd64.json"

        elif not result.exists():
            raise RuntimeError(f"Cannot bootstrap clingo from sources on {self.host_platform}")

        return result

    def concretize(self) -> "spack.spec.Spec":
        # Read the prototype and mark it NOT concrete
        s = spack.spec.Spec.from_specfile(str(self.prototype_path()))
        s._mark_concrete(False)

        # Tweak it to conform to the host architecture
        for node in s.traverse():
            node.architecture.os = str(self.host_os)
            node.architecture = self.host_architecture

            if node.name == "gcc-runtime":
                node.versions = self.host_compiler.versions

        # Can't use re2c@3.1 with Python 3.6
        if self.host_python.satisfies("@3.6"):
            s["re2c"].versions.versions = [spack.version.from_string("=2.2")]

        for edge in spack.traverse.traverse_edges([s], cover="edges"):
            if edge.spec.name == "python":
                edge.spec = self.host_python

            if edge.spec.name == "bison" and self.external_bison:
                edge.spec = self.external_bison

            if edge.spec.name == "cmake" and self.external_cmake:
                edge.spec = self.external_cmake

            if edge.spec.name == self.host_compiler.name:
                edge.spec = self.host_compiler

            if "libc" in edge.virtuals:
                edge.spec = self.host_libc

        s._finalize_concretization()

        # Work around the fact that the installer calls Spec.dependents() and
        # we modified edges inconsistently
        return s.copy()

    def python_external_spec(self) -> "spack.spec.Spec":
        """Python external spec corresponding to the current running interpreter"""
        result = spack.spec.Spec(spec_for_current_python(), external_path=sys.exec_prefix)
        return self._external_spec(result)

    def libc_external_spec(self) -> "spack.spec.Spec":
        detector = spack.compilers.libraries.CompilerPropertyDetector(self.host_compiler)
        result = detector.default_libc()
        return self._external_spec(result)

    def _external_spec(self, initial_spec) -> "spack.spec.Spec":
        initial_spec.namespace = "builtin"
        initial_spec.architecture = self.host_architecture
        for flag_type in spack.spec.FlagMap.valid_compiler_flags():
            initial_spec.compiler_flags[flag_type] = []
        return spack.spec.parse_with_version_concrete(initial_spec)
